/*
 * Copyright (c) 2001-2007, Inversoft, All Rights Reserved
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied. See the License for the specific
 * language governing permissions and limitations under the License.
 */
package org.inversoft.vertigo.database;

import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.PreparedStatement;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.List;
import java.util.ArrayList;
import java.util.logging.Logger;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

import org.w3c.dom.Document;

import net.java.lang.URLClassLoaderResolver;
import net.java.util.Version;
import com.mysql.jdbc.jdbc2.optional.MysqlDataSource;

/**
 * <p>
 * This class is used to apply a set of run files to a database.
 * This requires that the database connection has already been established
 * and that the database is correctly setup to handle the SQL statements
 * from the run files.
 * </p>
 *
 * <p>
 * Patch files are located on the file system in a specific directory.
 * Each file within that directory might be executed, depending on how
 * this patcher is constructed.
 * </p>
 *
 * <p>
 * The run looks are the last version, which is essentially the last
 * run file that was executed on the current database. This allows
 * a production or staging database to be patched by figuring out what
 * the version of the database in that environment is and passing that
 * value into the patcher. The patcher will then only apply run files
 * that are newer than that version.
 * </p>
 *
 * <p>
 * Patch files must have a specific naming scheme in order to determine
 * what their currentVersions are. The name must be in this form:
 * </p>
 *
 * <pre>
 * version-name.sql
 * </pre>
 *
 * <p>
 * For example, <code>14-update-user-table.sql</code> could be a run
 * file. The version must be an integer value that is parsable be the
 * Integer class. This version will determine what the version of
 * the database is after the patcher has run all of the run files as
 * well as whice run files to run and the order to run them in.
 * </p>
 *
 * @author  Brian Pontarelli
 */
public class DatabasePatcher {
    private static final Logger logger = Logger.getLogger(DatabasePatcher.class.getName());
    private Connection connection;
    private String projectName;
    private File patchSQLDir;
    private File seedSQLDir;
    private Map<String, Version> currentVersions = new HashMap<String, Version>();
    private boolean seedOnly;

    /**
     * This main method provides an external execution point to the DatabasePatcher. Typically this
     * is executed via a script; (ant, shell, etc)
     *
     * @param   args 1 - persistence unit, 2 - jdbc-connection-url, 3 - project name
     * @throws  SQLException on sql exception
     * @throws  IOException on io exception
     */
    public static void main(String... args) throws SQLException, IOException {
        if (args.length != 3) {
            System.err.println("Usage: DatabasePatcher <db-url> <project-name> <root-dir>");
            System.exit(1);
        }

        String dbURL = args[0];
        String projectName = args[1];
        String rootDir = args[1];

        MockJNDI jndi = new MockJNDI();
        MysqlDataSource dataSource = DatabaseTools.setupJDBCURLandJNDI(jndi, dbURL, projectName);
        jndi.activate();

        // Using the SQL run system we need to load up the database instance and then the tables
        // and any modifications to date in order. This works based on version numbers (1, 2, ...)
        // So we just force it by setting the last version to zero
        DatabasePatcher patcher = new DatabasePatcher(dataSource.getConnection(), projectName,
            new File(rootDir, "patch"), new File(rootDir, "seed"), true);
        patcher.patch();
    }

    /**
     * Creates a new database patcher that will use the given connection, run SQL directory and
     * last version number.
     *
     * @param   connection The JDBC connection to use when patching.
     * @param   projectName The name of this project used to determine currentVersions.
     * @param   patchSQLDir The directory on the file system that contains the patch files.
     * @param   seedSQLDir The directory on the file system that contains the seed files.
     * @param   seedOnly Determines if the patch files should be run. If the database loader is using
     *          Hibernate to create the tables, the patch files should not be executed.
     * @throws  IllegalArgumentException If the run SQL directory is not valid.
     */
    public DatabasePatcher(Connection connection, String projectName, File patchSQLDir, File seedSQLDir,
            boolean seedOnly) {
        if (patchSQLDir.isFile()) {
            throw new IllegalArgumentException("Invalid run SQL directory [" +
                patchSQLDir.getAbsolutePath() + "]. That is a file.");
        }

        this.connection = connection;
        this.projectName = projectName;
        this.patchSQLDir = patchSQLDir;
        this.seedSQLDir = seedSQLDir;
        this.seedOnly = seedOnly;
    }

    /**
     * Runs the run files using the method described in the class comment.
     *
     * @throws  IOException If anything goes wrong while patching.
     * @throws  SQLException If loading or updating the currentVersions table failed.
     */
    public void patch() throws IOException, SQLException {
        logger.info("Patching existing database");
        DatabaseTools.disableKeys(connection);

        try {
            Map<String, String> componentJarMapping = new HashMap<String, String>();
            Enumeration<URL> componentURLs = this.getClass().getClassLoader().getResources("META-INF/component.xml");
            while (componentURLs.hasMoreElements()) {
                URL url = componentURLs.nextElement();
                String jarPath = determineJarPath(url);
                String componentName = parseComponentName(url);
                componentJarMapping.put(jarPath, componentName);
                logger.info("Found component at [" + jarPath + "] named [" + componentName + "]");
            }

            // Load up the currentVersions from the database, if the table exists. If it doesn't exist, create
            // it
            loadVersions();

            // To store the component highest currentVersions for this run
            Map<String, Version> versions = new HashMap<String, Version>();

            // Based on the currentVersions from the database, we run the patch scripts using the JAR file name
            // as the component/library name in the database and we also update the version
            // First do this locally:
            if (!seedOnly) {
                runClasspath("META-INF/sql/patch", componentJarMapping, versions);
                runLocal(patchSQLDir, versions);
            }

            // Now run the seed files using the same logic
            runClasspath("META-INF/sql/seed", componentJarMapping, versions);
            runLocal(seedSQLDir, versions);

            // Update the currentVersions in the database using the highest that we found on this entire patch/seed run
            Set<String> componentNames = versions.keySet();
            for (String componentName : componentNames) {
                updateVersion(componentName, versions.get(componentName));
            }
        } finally {
            // Finally enable the keys again
            DatabaseTools.enableKeys(connection);
        }
    }

    /**
     * Construct the Versions mapping.
     *
     * @throws  SQLException If the load or create failed.
     */
    private void loadVersions() throws SQLException {
        Statement statement = connection.createStatement();
        try {
            statement.execute("describe currentVersions");

            logger.info("Loading the currentVersions table into memory");
            ResultSet rs = statement.executeQuery("select * from currentVersions");
            while (rs.next()) {
                String name = rs.getString("name");
                String version = rs.getString("version");
                currentVersions.put(name, new Version(version));
                logger.info("Found version for [" + name + "] of [" + version + "]");
            }
        } catch (SQLException e) {
            logger.info("The currentVersions table doesn't exist, creating it");
            statement.execute("create table currentVersions (name varchar(1000), version varchar(10))");
            statement.close();
        }
    }

    private String parseComponentName(URL url) {
        try {
            DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
            Document dom = builder.parse(url.openStream());
            return dom.getDocumentElement().getAttribute("name");
        } catch (Exception e) {
            // Bail!
            throw new RuntimeException("Unable to parse component.xml file", e);
        }
    }

    /**
     * Runs all the local SQL files.
     *
     * @param   dir The directory where the SQL files to run are located.
     * @param   versions The versions map that stores the latest version for this project based on
     *          the patch files that are executed.
     * @throws  IOException If the run failed.
     * @throws  SQLException If updating the currentVersions table failed.
     */
    private void runLocal(File dir, Map<String, Version> versions) throws IOException, SQLException {
        if (!dir.exists()) {
            return;
        }

        Version version = determineNextVersion(projectName);
        logger.info("Running local SQL scripts from [" + dir.getAbsolutePath() + "] starting at version [" +
            version + "]");
        MultipleScriptRunner runner = new MultipleScriptRunner(connection, dir, version);
        Version last = runner.run();
        Version previous = versions.get(projectName);
        if (last != null && (previous == null || last.compareTo(previous) > 0)) {
            versions.put(projectName, last);
        }
    }

    /**
     * Runs all the SQL files from the classpath located in the given path.
     *
     * @param   path The path to locate and run all the SQL files.
     * @param   componentJarMapping Used to translate between the JAR in the classpath and a component
     *          name.
     * @param   versions Map of component names to currentVersions.
     * @throws  IOException If the patch fails.
     * @throws  SQLException If updating the currentVersions table failed.
     */
    private void runClasspath(String path, Map<String, String> componentJarMapping, Map<String, Version> versions)
    throws IOException, SQLException {
        URLClassLoaderResolver resolver = new URLClassLoaderResolver();
        resolver.find(new URLClassLoaderResolver.NameEndsWith(".sql"), true, path);

        // Struct class
        class Struct {
            List<URL> urls = new ArrayList<URL>();
            String componentName;
            Version version;
        }

        Map<String, Struct> jarInfo = new HashMap<String,Struct>();
        Set<URL> urls = resolver.getMatches();
        for (URL url : urls) {
            String jarPath = determineJarPath(url);
            Struct info = jarInfo.get(jarPath);
            if (info == null) {
                info = new Struct();
                info.componentName = componentJarMapping.get(jarPath);
                info.version = determineNextVersion(info.componentName);
                jarInfo.put(jarPath, info);
            }

            info.urls.add(url);
        }

        Set<String> jarPaths = jarInfo.keySet();
        for (String jarPath : jarPaths) {
            Struct info = jarInfo.get(jarPath);
            logger.info("Running SQL scripts from the classpath JAR [" + jarPath + "] starting at version [" +
                info.version + "]");
            MultipleScriptRunner runner = new MultipleScriptRunner(connection, info.urls, info.version);
            Version last = runner.run();
            Version previous = versions.get(info.componentName);
            if (last != null && (previous == null || last.compareTo(previous) > 0)) {
                versions.put(info.componentName, last);
            }
        }
    }

    private String determineJarPath(URL url) {
        String urlStr = url.toString();
        int index = urlStr.indexOf('!');
        if (index <= 0) {
            return null;
        }

        // Strip the file: url part and the !/path part
        int prefixLength = urlStr.startsWith("jar:file:") ? 9 : 5;
        return urlStr.substring(prefixLength, index);
    }

    /**
     * Determines the current version for a component and figures out the next version. This is
     * accomplished by incrementing the patch number. This is important because it will cause the
     * patcher only to execute SQL scripts that contain currentVersions AFTER the current version.
     *
     * @param   componentName The name of the component.
     * @return  The next Version of the component from the <strong>version</strong> database table
     *          as described in the method commnt or a new version of <strong>0.0</strong> if there
     *          isn't a version for the component in the database.
     */
    private Version determineNextVersion(String componentName) {
        Version version = currentVersions.get(componentName);
        if (version == null) {
            version = new Version("0");
        }

        return new Version(version.getMajor(), version.getMinor(), version.getPatch() + 1);
    }

    /**
     * Updates the version table in the database with the last version from the SQL files that were
     * executed.
     *
     * @param   name The name of the component or project.
     * @param   last The last version run.
     * @throws  SQLException If the update failed.
     */
    private void updateVersion(String name, Version last) throws SQLException {
        PreparedStatement statement = connection.prepareStatement("update currentVersions set version = ? where name = ?");
        statement.setString(1, name);
        statement.setString(2, last.toString());
        int results = statement.executeUpdate();
        if (results == 0) {
            logger.info("Set version for [" + name + "] to [" + last + "]");
            statement = connection.prepareStatement("insert into currentVersions (name,version) values (?,?)");
            statement.setString(1, name);
            statement.setString(2, last.toString());
            results = statement.executeUpdate();
            if (results == 0) {
                throw new RuntimeException("Unable to insert or update currentVersions table for name [" +
                    name + "] and version [" + last.toString() + "]");
            }
        } else {
            logger.info("Updated version for [" + name + "] to [" + last + "]");
        }
    }
}